package org.optionparser;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Vector;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
 * Main class for option parser. Real all the options, parses arguments and returns results.
 *
 */
public class OptionParser {

	
	private boolean isParsed;
	/**
	 * List of already defined options 
	 */
	private List<Option> definedOptions;
	/**
	 * OptionPrinter to print usage informations
	 */
	private OptionPrinter optionPriner;
	private String[] commadLineArguments;
	private List<String> arguments;
	HashMap<String, String> results;

	/**
	 * Constructor, creates new instance of OptionParser class and sets initial
	 * values
	 * 
	 * @param args
	 *            Received command line arguments
	 */
	public OptionParser(String[] args) {
		this.commadLineArguments = args;
		this.isParsed = false;
		this.definedOptions = new Vector<Option>();
		this.optionPriner = new OptionPrinter();
		results = new HashMap<>();
		arguments = new ArrayList<>();
	}

	/**
	 * Main method used for creating new options with specified properties In
	 * this method every property for Option can be specified For better user
	 * experience, this method is private, and users can add their options using
	 * some symplyfied methods
	 * 
	 * 
	 * @param shortName
	 *            Short name of option
	 * @param longName
	 *            Long name of option
	 * @param optionType
	 *            Type of option (Required, Optional..)
	 * @param parameterType
	 *            Type of option paramater (Required, Optional..)
	 * @param parameterClass
	 *            Class of option parameter (String, Boolean ..)
	 * @param parameterName
	 *            Name of parameter
	 * @param lowerBound
	 *            If parameter class is Integer, this is the lower bound
	 *            criterium
	 * @param upperBound
	 *            If parameter class is Integer, this is the lower bound
	 *            criterium
	 * @param expectedValues
	 *            Expected Values for parameter (values allowed for parameter)
	 * @param description
	 *            Description of option
	 * @throws NameException
	 */
	private void addOption(String shortName, String longName,
			OptionType optionType, ParameterType parameterType,
			ParameterClass parameterClass, String parameterName,
			Integer lowerBound, Integer upperBound, String[] expectedValues,
			String description) throws NameException {
		// if any name is null, convert to empty string
		shortName = Option.returnValidString(shortName);
		//checks, if the short name is not longer than 1 character
		if (shortName.length() > 1)
		{
			throw new NameException(NameExceptionType.ShortNameTooLong, "Short name must have 1 character");
		}
		longName = Option.returnValidString(longName);
		parameterName = Option.returnValidString(parameterName);
		description = Option.returnValidString(description);
		// if not both names are empty
		if (!((shortName.isEmpty()) && (longName.isEmpty()))) {
			// are names unique?
			if (areNamesUnique(shortName, longName)) {
				// create new option and add to definedOptions list
				Option option = new Option(shortName, longName, optionType,
						parameterType, parameterClass, parameterName,
						lowerBound, upperBound, expectedValues, description);
				this.definedOptions.add(option);
			} else {
				throw new NameException(NameExceptionType.NamesNotUnique, "Specified names are not unique!");
			}
		} else {
			throw new NameException(NameExceptionType.NoValidNameDefined, "No valid name was specified");
		}
	}

	/*
	 * simplyfied methods for adding required options
	 */

	/**
	 * Creates a new optional Option which does not contain parameter
	 * 
	 * @param shortName
	 *            Short name of option
	 * @param longName
	 *            Long name of option
	 * @param description
	 *            Description of option
	 * @throws NameException
	 */
	public void addOptionalNoParameterOption(String shortName, String longName,
			String description) throws NameException {
		addOption(shortName, longName, OptionType.Optional,
				ParameterType.Forbidden, ParameterClass.None, null, null, null,
				null, description);
	}

	/**
	 * Creates a new optional option with String parameter
	 * 
	 * @param shortName
	 *            Short name of option
	 * @param longName
	 *            Long name of option
	 * @param parameterType
	 *            Type of parameter (Required, Optional..)
	 * @param parameterName
	 *            Name of parameter
	 * @param expectedValues
	 *            Expected Values for parameter (values allowed for parameter)
	 * @param description
	 *            Description of option
	 * @throws NameException
	 */
	public void addOptionalStringOption(String shortName, String longName,
			ParameterType parameterType, String parameterName,
			String[] expectedValues, String description)
			throws NameException {
		addOption(shortName, longName, OptionType.Optional, parameterType,
				ParameterClass.String, parameterName, null, null,
				expectedValues, description);

	}

	/**
	 * Creates a new optional option with Integral parameter If bounds are
	 * specified, Parameter can have value from lower bound (inclusive) to upper
	 * bound (inclusive) Only one boundary can also by specified
	 * 
	 * @param shortName
	 *            Short name of option
	 * @param longName
	 *            Long name of option
	 * @param parameterType
	 *            Type of parameter (Required, Optional..)
	 * @param parameterName
	 *            Name of parameter
	 * @param lowerBound
	 *            The lower bound of parameter value
	 * @param upperBound
	 *            The upper bound of parameter value
	 * @param description
	 *            Description of option
	 * @throws NameException
	 */
	public void addOptionalIntegerOption(String shortName, String longName,
			ParameterType parameterType, String parameterName,
			Integer lowerBound, Integer upperBound, String description)
			throws NameException {
		addOption(shortName, longName, OptionType.Optional, parameterType,
				ParameterClass.Integer, parameterName, lowerBound, upperBound,
				null, description);
	}

	/**
	 * Creates a new optional option with Boolean parameter
	 * 
	 * @param shortName
	 *            Short name of option
	 * @param longName
	 *            Long name of option
	 * @param parameterType
	 *            Type of parameter (Required, Optional..)
	 * @param parameterName
	 *            Name of parameter
	 * @param description
	 *            Description of option
	 * @throws NameException
	 */
	public void addOptionalBooleanOption(String shortName, String longName,
			ParameterType parameterType, String parameterName,
			String description) throws NameException {
		addOption(shortName, longName, OptionType.Optional, parameterType,
				ParameterClass.Boolean, parameterName, null, null, null,
				description);
	}

	/*
	 * Simplified methods for adding optional options
	 */

	/**
	 * Creates a new requred Option which does not contain parameter
	 * 
	 * @param shortName
	 *            Short name of option
	 * @param longName
	 *            Long name of option
	 * @param description
	 *            Description of option
	 * @throws NameException
	 */
	public void addRequiredNoParameterOption(String shortName, String longName,
			String description) throws NameException {
		addOption(shortName, longName, OptionType.Required,
				ParameterType.Forbidden, ParameterClass.None, null, null, null,
				null, description);
	}

	/**
	 * Creates a new required option with String parameter
	 * 
	 * @param shortName
	 *            Short name of option
	 * @param longName
	 *            Long name of option
	 * @param parameterType
	 *            Type of parameter (Required, Optional..)
	 * @param parameterName
	 *            Name of parameter
	 * @param expectedValues
	 *            Expected Values for parameter (values allowed for parameter)
	 * @param description
	 *            Description of option
	 * @throws NameException
	 */
	public void addRequiredStringOption(String shortName, String longName,
			ParameterType parameterType, String parameterName,
			String[] expectedValues, String description)
			throws NameException {
		addOption(shortName, longName, OptionType.Required, parameterType,
				ParameterClass.String, parameterName, -1, -1, expectedValues,
				description);

	}

	/**
	 * Creates a new required option with Integral parameter If bounds are
	 * specified, Parameter can have value from lower bound (inclusive) to upper
	 * bound (inclusive) Only one boundary can also by specified
	 * 
	 * @param shortName
	 *            Short name of option
	 * @param longName
	 *            Long name of option
	 * @param parameterType
	 *            Type of parameter (Required, Optional..)
	 * @param parameterName
	 *            Name of parameter
	 * @param lowerBound
	 *            The lower bound of parameter value
	 * @param upperBound
	 *            The upper bound of parameter value
	 * @param description
	 *            Description of option
	 * @throws NameException
	 */
	public void addRequiredIntegerOption(String shortName, String longName,
			ParameterType parameterType, String parameterName, int lowerBound,
			int upperBound, String description) throws NameException {
		addOption(shortName, longName, OptionType.Required, parameterType,
				ParameterClass.Integer, parameterName, lowerBound, upperBound,
				null, description);
	}

	/**
	 * Creates a new required option with Boolean parameter
	 * 
	 * @param shortName
	 *            Short name of option
	 * @param longName
	 *            Long name of option
	 * @param parameterType
	 *            Type of parameter (Required, Optional..)
	 * @param parameterName
	 *            Name of parameter
	 * @param description
	 *            Description of option
	 * @throws NameException
	 */
	public void addRequiredBooleanOption(String shortName, String longName,
			ParameterType parameterType, String parameterName,
			String description) throws NameException {
		addOption(shortName, longName, OptionType.Required, parameterType,
				ParameterClass.Boolean, parameterName, -1, -1, null,
				description);
	}

	/**
	 * Checks, if there is already option with same names as names defined in
	 * arguments.
	 * 
	 * @param shortName
	 *            Short name of option
	 * @param longName
	 *            Long name of option
	 * @return True, if both names are unique, or False, if at least one name
	 *         already exists in collection
	 */
	private boolean areNamesUnique(String shortName, String longName) {
		if (this.definedOptions.size() > 0) {
			String optionShortName = "";
			String optionLongName = "";
			for (Option option : this.definedOptions) {
				// get option names
				optionShortName = option.getCanonicalShortName();
				optionLongName = option.getCanonicalLongName();
				// check for equality
				if ((optionShortName.equals(shortName) && !shortName.equals(""))
						|| (optionLongName.equals(longName)&& !longName.equals(""))) {
					return false;
				}
			}
			return true;
		} else {
			// defined options are empty
			return true;
		}
	}

	/**
	 * Main parse method.
	 * 
	 * @throws ParseException
	 *             if there were an error during parsing.
	 */
	public void parse() throws ParseException {
		boolean readingAgruments = false;
		Option lastOption = null;

		for (String argument : commadLineArguments) {
			if (readingAgruments == false) {
				if (argument.matches("--")) {
					readingAgruments = true;
				} else if (argument.matches("-[a-zA-Z]")) {
					String name = argument.substring(1);
					Option option = findOptionByShortName(name);
					lastOption = option;
					insertIntoResuls(option, "");
				} else if (argument.matches("--[a-zA-Z0-9]+")) {
					String name = argument.substring(2);
					Option option = findOptionByLongName(name);
					lastOption = option;
					insertIntoResuls(option, "");
				} else if (argument.matches("--[a-zA-Z0-9]+=[a-zA-Z0-9]+")) {
					Pattern regularExpression = Pattern.compile("[a-zA-Z0-9]+");
					Matcher matcher = regularExpression.matcher(argument);

					matcher.find();
					String name = matcher.group(0);
					Option option = findOptionByLongName(name);
					insertIntoResuls(option, "");
					lastOption = null;

					matcher.find();
					String parameter = matcher.group(0);
					parseOptionWithParameter(option, parameter);
				} else {
					if (lastOption == null) {
						arguments.add(argument);
					} else {
						parseOptionWithParameter(lastOption, argument);
						
					}
					lastOption=null;
				}
			} else {
				arguments.add(argument);
				lastOption=null;
			}

		}

		for (Option option : definedOptions) {
			if (option.getOptionType() == OptionType.Required
					&& option.isFoundOnCommandline() == false) {
				throw new ParseException(
						ParseExceptionType.RequiredOptionNotUsed, "Option "
								+ option + " is required");
			}
			if (option.getParameterType() == ParameterType.Required
					&& results.get(option.toCanonicalString()) != null
					&& results.get(option.toCanonicalString()).equals(""))
				throw new ParseException(
						ParseExceptionType.RequiredOptionNotUsed,
						"Parameter for option " + option + " is required");
		}
		isParsed = true;

	}

	/**
	 * Checks option argument if it is forbidden. Calls methods, which will
	 * chcek argument for class and value.
	 * 
	 * @param option
	 * @param argument
	 * @throws ParseException
	 *             if there were a parse error.
	 */
	private void parseOptionWithParameter(Option option, String argument)
			throws ParseException {
		if (option.getParameterType() != ParameterType.Forbidden) {
			checkArgumentClass(option, argument, option.getParameterClass());
			checkArgumentValue(option, argument, option.getExpectedValues());
			results.put(option.getCanonicalLongName(), argument);
			results.put(option.getCanonicalShortName(), argument);
		} else {
			throw new ParseException(ParseExceptionType.ForbiddenArgument,
					"Argument for option " + option + " is forbidden");
		}

	}

	/**
	 * Check option argument value.
	 * 
	 * @param option
	 * @param argument
	 * @param expectedValues
	 * @throws ParseException
	 *             if the value doens't match expected value
	 */
	private void checkArgumentValue(Option option, String argument,
			String[] expectedValues) throws ParseException {
		if (expectedValues == null || expectedValues.length == 0)
			return;
		boolean found = false;
		for (String value : expectedValues) {
			if (value.equals(argument)) {
				found = true;
				break;
			}
		}
		if (found == false) {
			String expectedValuesString = "";
			for (String value : expectedValues) {
				expectedValuesString += value + " ";
			}
			throw new ParseException(
					ParseExceptionType.ArgumnetDoesntMatchExpectedValues,
					"Argument for option " + option + " must be one of these:"
							+ expectedValuesString);
		}
	}

	/**
	 * Check argument class and bounds of Integer argument.
	 * 
	 * @param option
	 * @param argument
	 * @param parameterClass
	 * @throws ParseException
	 *             if the class doesn't match or integer argument is out of
	 *             bounds.
	 */
	private void checkArgumentClass(Option option, String argument,
			ParameterClass parameterClass) throws ParseException {
		if (parameterClass == ParameterClass.Integer) {
			try {
				Integer argument_value = new Integer(argument);
				if (option.getLowerBound() != null) {
					if (argument_value < option.getLowerBound())
						throw new ParseException(
								ParseExceptionType.ArgumentOutOfBounds,
								"Argument value for option " + option
										+ " cannot be lower than "
										+ option.getLowerBound());
				}
				if (option.getUpperBound() != null) {
					if (argument_value > option.getUpperBound())
						throw new ParseException(
								ParseExceptionType.ArgumentOutOfBounds,
								"Argument value for option " + option
										+ " cannot be greater than "
										+ option.getUpperBound());
				}
			} catch (NumberFormatException e) {
				throw new ParseException(ParseExceptionType.WrongArgumentClass,
						"Argument for option " + option + " must be an Integer");
			}
		} else if (parameterClass == ParameterClass.Boolean) {
			if (!argument.equals("true") && !argument.equals("false")) {
				throw new ParseException(ParseExceptionType.WrongArgumentClass,
						"Argument for option " + option + " must be Boolean");
			}
		} else if (parameterClass == ParameterClass.None
				|| parameterClass == ParameterClass.String) {
			return;
		}

	}

	/**
	 * Insert option into result map.
	 * 
	 * @param option
	 *            to be inserted
	 * @param parameter
	 *            to be inserted
	 * @throws ParseException
	 *             if the option occurs multiple times.
	 */
	private void insertIntoResuls(Option option, String parameter)
			throws ParseException {
		String longName = option.getCanonicalLongName();
		String shortName = option.getCanonicalShortName();

		if (results.containsKey(longName) || results.containsKey(shortName)) {
			throw new ParseException(
					ParseExceptionType.MultipleOptionOcuurence, "Option "
							+ option + " occurs multiple times");
		} else {
			if (longName != "")
				results.put(longName, parameter);
			if (shortName != "")
				results.put(shortName, parameter);
		}
	}

	/**
	 * Returns Option by long name.
	 * 
	 * @param name
	 * @return Option
	 * @throws ParseException
	 *             if option is not found
	 */
	private Option findOptionByShortName(String name) throws ParseException {
		for (Option option : definedOptions) {
			if (option.getCanonicalShortName() != null
					&& option.getCanonicalShortName().equals(name)) {
				option.setFoundOnCommandline(true);
				return option;
			}
		}
		throw new ParseException(ParseExceptionType.NotDefinedOption, "Option "
				+ name + " hasn't been defined");
	}

	/**
	 * Returns Option by short name.
	 * 
	 * @param name
	 * @return Option
	 * @throws ParseException
	 *             if option is not found
	 */
	private Option findOptionByLongName(String name) throws ParseException {
		for (Option option : definedOptions) {
			if (option.getCanonicalLongName() != null
					&& option.getCanonicalLongName().equals(name)) {
				option.setFoundOnCommandline(true);
				return option;
			}
		}
		throw new ParseException(ParseExceptionType.NotDefinedOption, "Option "
				+ name + " hasn't been defined");
	}

	/*
	 * -------------------------------Printer
	 * methods------------------------------
	 */
	/**
	 * Prints the usage information based on defined options to System.out
	 */
	public void printOptionsInfo() {
		this.optionPriner.printOptions(definedOptions);
	}

	/**
	 * Sets the name of the program Program name line would be printed, only if
	 * valid program name is specified!
	 * 
	 * @param programName
	 *            the programName to set
	 */
	public void setProgramName(String programName) {
		if (programName == null) {
			programName = "";
		}
		this.optionPriner.setProgramName(programName);
	}

	public void setPrinterHeading(String heading) {
		this.optionPriner.setHeading(heading);
	}

	/**
	 * Specifies, whether the heading line should be printed
	 * 
	 * @param printHeading
	 *            If true, heading WILL be printed, if false, it WONT be printed
	 */
	public void setPrinterPrintHeading(boolean printHeading) {
		this.optionPriner.setPrintSeparatorOption(printHeading);
	}

	/**
	 * Sets the value of line with program name (without program name) Program
	 * name line would be printed, only if valid program name is specified!
	 * 
	 * For example default value "[options] [arguments...]" prints
	 * "ProgramName [options] [arguments...]"
	 * 
	 * @param programNameLine
	 *            The program name line
	 */
	public void setPrinterProgramNameLine(String programNameLine) {
		this.optionPriner.setProgramNameLine(programNameLine);
	}

	/**
	 * Sets the description for the separator symbol Can be used f.e. for
	 * translation
	 * 
	 * @param separatorDescription
	 *            Description of separator symbol
	 */
	public void setPrinterSeparatorDescription(String separatorDescription) {
		this.optionPriner.setSeparatorDescription(separatorDescription);
	}

	/**
	 * Specifies, whether the separator (used to separate options and
	 * arguments), by default the -- symbol should be also printed in list of
	 * options
	 * 
	 * @param printSeparatorOption
	 *            If true, the separator WILL be printed, if false, it WONT be
	 *            printed
	 */
	public void setPrinterPrintSeparatorOption(boolean printSeparatorOption) {
		this.optionPriner.setPrintSeparatorOption(printSeparatorOption);
	}

	/*
	 * -------------------------------/Printer
	 * methods-----------------------------
	 */

	/**
	 * Returns Map of results. Key is option a value is argument.
	 * 
	 * @return results
	 * @throws ParseException
	 *             if command line has not been parsed.
	 */
	public Map<String, String> getResults() throws ParseException {
		if (isParsed)
			return results;
		else
			throw new ParseException(ParseExceptionType.CMDNotParsedYet,
					"Comandline hasn't been parsed");
	}

	/**
	 * Returns result.
	 * 
	 * @param name
	 * @return If option doesn't occurs null will be returned. If option occurs
	 *         without argument "" will be returned. If option occurs with
	 *         argument argument value will be returned.
	 * @throws ParseException
	 */
	public String getResult(String name) throws ParseException {
		if (isParsed)
			return results.get(name);
		else
			throw new ParseException(ParseExceptionType.CMDNotParsedYet,
					"Comandline hasn't been parsed");
	}

	/**
	 * Returns list of arguments.
	 * 
	 * @return arguments
	 * @throws ParseException
	 */
	public List<String> getArguments() throws ParseException {
		if (isParsed)
			return arguments;
		else
			throw new ParseException(ParseExceptionType.CMDNotParsedYet,
					"Comandline hasn't been parsed");
	}

	/**
	 * Indicates if option occurs on command line.
	 * 
	 * @param name
	 * @throws ParseException
	 */
	public boolean containsOption(String name) throws ParseException {
		if (isParsed)
			return results.containsKey(name);
		else
			throw new ParseException(ParseExceptionType.CMDNotParsedYet,
					"Comandline hasn't been parsed");
	}

	/**
	 * Indicates if option contains parameter.
	 * 
	 * @param name
	 * @return true if option exists and contains parameter.
	 * @throws ParseException
	 */
	public boolean containsParameter(String name) throws ParseException {
		if (isParsed) {
			if (results.containsKey(name)) {
				return results.get(name).equals("");
			} else {
				return false;
			}
		} else
			throw new ParseException(ParseExceptionType.CMDNotParsedYet,
					"Comandline hasn't been parsed");
	}
}
